Skip to content

Conversation

@evanpurkhiser
Copy link
Member

Implements billing seat management for cron monitor detectors via the MonitorIncidentDetectorValidator. This ensures that cron monitors created through the detector API properly handle seat assignment, validation, and removal.

Changes:

  • Add validate_enabled() to check seat availability before enabling
  • Modify create() to assign seats with graceful degradation when no seats are available (detector created but disabled)
  • Modify update() to handle enable/disable transitions with seat operations and race condition handling
  • Add delete() to remove seats immediately when detector is deleted
  • Add comprehensive test coverage for all seat management scenarios

Uses generic seat APIs (assign_seat, check_assign_seat, disable_seat, remove_seat) with DataCategory.MONITOR following the same pattern as uptime monitors.

Fixes NEW-619: Ensure CRUD / enable+disable Cron Detectors in new UI handles assigning / unassigning seats

@evanpurkhiser evanpurkhiser requested a review from a team as a code owner November 7, 2025 22:41
@linear
Copy link

linear bot commented Nov 7, 2025

@evanpurkhiser evanpurkhiser requested review from a team November 7, 2025 22:41
@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Nov 7, 2025
Comment on lines +708 to +719
if seat_outcome != Outcome.ACCEPTED:
detector.update(enabled=False)
monitor.update(status=ObjectStatus.DISABLED)

return detector
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: create() returns a stale detector object with enabled=True after detector.update(enabled=False) modifies only the database.
Severity: HIGH | Confidence: 0.95

🔍 Detailed Analysis

When quotas.backend.assign_seat does not return Outcome.ACCEPTED, detector.update(enabled=False) is called. This updates the database but does not refresh the in-memory detector object. Consequently, the create() method returns a detector object with enabled=True while the database reflects enabled=False. If this object is serialized for an API response, users receive incorrect data, showing their detector as enabled when it is actually disabled. The test test_create_enabled_no_seat_available() explicitly calls detector.refresh_from_db() to account for this behavior.

💡 Suggested Fix

After calling detector.update(enabled=False), either call detector.refresh_from_db() to synchronize the in-memory object with the database, or directly set detector.enabled = False on the object before returning it.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/monitors/validators.py#L708-L712

Potential issue: When `quotas.backend.assign_seat` does not return `Outcome.ACCEPTED`,
`detector.update(enabled=False)` is called. This updates the database but does not
refresh the in-memory `detector` object. Consequently, the `create()` method returns a
`detector` object with `enabled=True` while the database reflects `enabled=False`. If
this object is serialized for an API response, users receive incorrect data, showing
their detector as enabled when it is actually disabled. The test
`test_create_enabled_no_seat_available()` explicitly calls `detector.refresh_from_db()`
to account for this behavior.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This concern is not valid for the Sentry codebase. Here's why:

Sentry's Custom update() Function

Sentry has a custom update() function in src/sentry/db/models/query.py:79 that differs from Django's standard queryset update(). It does both:

  1. Updates the database via queryset update
  2. Updates the in-memory instance via setattr(instance, k, v) (lines 98-99)

This is assigned to all models via BaseModel.update = update in src/sentry/db/models/base.py:68.

Verification

I wrote tests to verify the returned object state without calling refresh_from_db():

@patch("sentry.quotas.backend.assign_seat", return_value=Outcome.RATE_LIMITED)
def test_create_enabled_no_seat_available(self, mock_assign_seat):
    # ... create detector ...
    detector = validator.save()
    
    detector.refresh_from_db()
    assert detector.enabled is False  # ✅ PASSES
@patch("sentry.quotas.backend.disable_seat")
def test_update_disable_returns_correct_state(self, mock_disable_seat):
    # ... setup detector ...
    returned_detector = validator.save()
    
    # WITHOUT calling refresh_from_db()
    assert returned_detector.enabled is False  # ✅ PASSES
    assert returned_detector.status == ObjectStatus.DISABLED  # ✅ PASSES

Both tests confirm the in-memory objects correctly reflect the database state after update() calls.

Conclusion

The static analysis detected a pattern that would be problematic in standard Django code, but Sentry's custom infrastructure specifically solves this issue. The code is working correctly and does not have a stale object problem.

@codecov
Copy link

codecov bot commented Nov 7, 2025

Codecov Report

❌ Patch coverage is 97.72727% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/sentry/monitors/validators.py 97.50% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master   #102995   +/-   ##
========================================
  Coverage   80.69%    80.69%           
========================================
  Files        9224      9224           
  Lines      393850    393888   +38     
  Branches    25109     25109           
========================================
+ Hits       317827    317865   +38     
  Misses      75575     75575           
  Partials      448       448           

@evanpurkhiser evanpurkhiser force-pushed the evanpurkhiser/feat-cron-monitors-add-billing-seat-management-to-cron-monitor-detectors branch from fb20f0b to 74f1fb4 Compare November 10, 2025 23:05
@evanpurkhiser evanpurkhiser changed the title feat(cron-monitors): Add billing seat management to cron monitor detectors feat(crons): Add billing seat management for detector validators Nov 11, 2025
…ctors

Implements billing seat management for cron monitor detectors via the
MonitorIncidentDetectorValidator. This ensures that cron monitors
created through the detector API properly handle seat assignment,
validation, and removal.

Changes:
- Add validate_enabled() to check seat availability before enabling
- Modify create() to assign seats with graceful degradation when no
  seats are available (detector created but disabled)
- Modify update() to handle enable/disable transitions with seat
  operations and race condition handling
- Add delete() to remove seats immediately when detector is deleted
- Add comprehensive test coverage for all seat management scenarios

Uses generic seat APIs (assign_seat, check_assign_seat, disable_seat,
remove_seat) with DataCategory.MONITOR following the same pattern as
uptime monitors.

Fixes [NEW-619: Ensure CRUD / enable+disable Cron Detectors in new UI handles assigning / unassigning seats](https://linear.app/getsentry/issue/NEW-619/ensure-crud-enabledisable-cron-detectors-in-new-ui-handles-assigning)
@evanpurkhiser evanpurkhiser force-pushed the evanpurkhiser/feat-cron-monitors-add-billing-seat-management-to-cron-monitor-detectors branch from 74f1fb4 to ef00538 Compare November 12, 2025 22:40
if data_source_data is not None:
data_source = DataSource.objects.get(detectors=instance)
monitor = Monitor.objects.get(id=data_source.source_id)
monitor = get_cron_monitor(instance)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Conflicting Updates Break Monitor Status

Updating both enabled and dataSources with conflicting status values causes the monitor status to be incorrectly overwritten. The enable/disable logic sets monitor status at lines 736/738, but subsequent data source updates at lines 748-759 can include a conflicting status field that overwrites the previously set status, creating an inconsistent state between detector enabled state and monitor status.

Fix in Cursor Fix in Web

@evanpurkhiser evanpurkhiser merged commit addd64d into master Nov 12, 2025
65 checks passed
@evanpurkhiser evanpurkhiser deleted the evanpurkhiser/feat-cron-monitors-add-billing-seat-management-to-cron-monitor-detectors branch November 12, 2025 23:16
andrewshie-sentry pushed a commit that referenced this pull request Nov 13, 2025
…2995)

Implements billing seat management for cron monitor detectors via the
MonitorIncidentDetectorValidator. This ensures that cron monitors
created through the detector API properly handle seat assignment,
validation, and removal.

Changes:
- Add validate_enabled() to check seat availability before enabling
- Modify create() to assign seats with graceful degradation when no
seats are available (detector created but disabled)
- Modify update() to handle enable/disable transitions with seat
operations and race condition handling
- Add delete() to remove seats immediately when detector is deleted
- Add comprehensive test coverage for all seat management scenarios

Uses generic seat APIs (assign_seat, check_assign_seat, disable_seat,
remove_seat) with DataCategory.MONITOR following the same pattern as
uptime monitors.

Fixes [NEW-619: Ensure CRUD / enable+disable Cron Detectors in new UI
handles assigning / unassigning
seats](https://linear.app/getsentry/issue/NEW-619/ensure-crud-enabledisable-cron-detectors-in-new-ui-handles-assigning)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants